Skip to content

feat(fe): switch OpenID callback to response_mode=form_post#4015

Merged
aterga merged 8 commits into
mainfrom
form-post-callback
Jun 19, 2026
Merged

feat(fe): switch OpenID callback to response_mode=form_post#4015
aterga merged 8 commits into
mainfrom
form-post-callback

Conversation

@sea-snake

@sea-snake sea-snake commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Sign-in with Apple silently loses the user's name and email, and some strict OIDC providers (Okta, Auth0) reject our hybrid-flow callback (by default): both are artifacts of response_mode=fragment, which OAuth 2.1 removes entirely and which also exposes the id_token in the callback URL. This implements Track A (§7) of the OpenID/SSO production-readiness design: the IdP now delivers the OAuth response with response_mode=form_post instead.

Changes

  • The IdP POSTs {id_token, state} to /callback; the frontend canister upgrades the POST to update mode (so the response is certified) and returns an HTML page whose single, CSP-hash-pinned inline script delivers the payload to the frontend: via BroadcastChannel for popup flows, via sessionStorage + /authorize?flow=openid-resume for 1-click same-tab flows. The flows are discriminated by the ii-openid-authorize-state marker — window.opener cannot tell them apart since the authorize tab is itself a popup opened by the relying party.
  • The POST arrives anonymously (the IdP submits the form), so the handler is a pure transport translator: the salt + nonce + caller() JWT redemption binding still happens through the existing signed-ingress flow.
  • RFC 6749 error reports ({error, error_description, state}) travel through the same page, so a misconfigured SSO app still surfaces its own message as OAuthProviderError instead of a generic failure.
  • Bodies that can't be translated (malformed, missing fields, oversized) get a 303 redirect to a new in-SPA error page (/callback-error) that renders the styled, localized error UI; the machine reason rides along as a query param to aid debugging a misconfigured IdP.
  • The callback page's CSP is scoped to its single hash-pinned inline script via a content_security_policy_override parameter on the shared header builder, rather than carving the CSP out of the SPA-wide headers.
  • Frontend: createRedirectURL requests form_post; extractIdTokenFromCallback validates the structured payload (state check first); resumeOpenId reads the payload from sessionStorage.
  • Hot-reload dev: a server hook performs the same translation (no canister fronts the dev server) and passes the canister's 303 through unfollowed; SvelteKit's CSRF origin check is disabled since it would reject the IdP's cross-origin form POST before the hook runs — adapter-static ships no server in production, so the check only ever applied to the dev server. In NO_HOT_RELOAD e2e the vite forward plugin routes the POST to the real canister handler.
  • The legacy fragment callback page is removed: the live popup utilities moved to $lib/utils/openID.ts.

Tests

  • Canister unit tests for the form parser, validation bounds, JSON/HTML escaping and CSP script-hash pinning, plus property tests for the charset predicates and the JSON embedding (output is always free of raw </>/& and round-trips).
  • PocketIC integration tests: POST /callback upgrade flag, success/error translation, malformed-body redirect to /callback-error, security headers, 405s.
  • FE unit tests updated for the payload shape; createRedirectURL form_post coverage.
  • Existing OpenID/SSO e2e suites pass against the real canister handler (1-click same-tab and wizard popup paths).

🤖 Generated with Claude Code

sea-snake and others added 2 commits June 12, 2026 12:35
The OAuth callback previously used response_mode=fragment: the IdP
redirected back with the id_token in the URL hash, which Apple Sign In
drops name/email claims under, Okta/Auth0 handle inconsistently for
hybrid flows, and OAuth 2.1 removes entirely. The id_token also ended
up visible in the callback URL.

The IdP now POSTs {id_token, state} to /callback. The frontend canister
upgrades the POST to update mode and translates it into a certified HTML
page whose single, CSP-hash-pinned inline script delivers the payload to
the frontend: via BroadcastChannel for popup flows, via sessionStorage +
/authorize?flow=openid-resume for 1-click same-tab flows (discriminated
by the ii-openid-authorize-state marker, since in the authorize flow the
tab itself is a popup opened by the relying party). RFC 6749 error
reports ({error, error_description, state}) travel through the same page
so a misconfigured SSO app still surfaces its own message as
OAuthProviderError instead of a generic failure.

The POST arrives anonymously (the IdP submits the form), so the handler
cannot redeem the JWT: the salt + nonce + caller() binding still happens
through the existing signed-ingress flow from the frontend.

In hot-reload dev, a server hook performs the same translation since no
canister fronts the dev server; SvelteKit's CSRF origin check is disabled
as it would reject the IdP's cross-origin form POST before the hook runs
(adapter-static ships no server in production, so the check only ever
applied to the dev server). In NO_HOT_RELOAD e2e, the vite forward plugin
already routes the POST to the real canister handler.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The IdP's form_post response is answered by the canister directly, so
the /callback route never renders: its GET page, the sendUrlToOpener
helper and the layout's origin-redirect exclusion for the path are dead.
The live popup utilities (redirectInPopup, CallbackPopupClosedError,
CallbackPayload) move from the route folder into $lib/utils/openID.ts
next to their only consumers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 12, 2026 13:24
@sea-snake sea-snake requested a review from a team as a code owner June 12, 2026 13:24
@zeropath-ai

zeropath-ai Bot commented Jun 12, 2026

Copy link
Copy Markdown

No security or compliance issues detected. Reviewed everything up to 115afa6.

Security Overview
Detected Code Changes
Change Type Relevant files
Configuration changes ► Cargo.lock
    Add form_urlencoded, proptest, serde dependencies
    Add proptest dependency
    Add serde dependency
Enhancement ► src/canister_tests/src/api.rs
    Add http_request_update function
    Add update_candid import
► src/frontend/src/hooks.server.ts
    Forward callback POST requests to canister
    Implement frontendCanisterOrigin function
► src/frontend/src/lib/utils/openID.test.ts
    Add tests for createRedirectURL
    Add tests for extractIdTokenFromCallback
    Normalize null error_description to undefined
    Test ID token extraction and error handling
► src/frontend/src/lib/utils/openID.ts
    Add CallbackPayloadSchema
    Add CallbackFieldsSchema
    Add isCallbackPayload function
    Add redirectInPopup function
    Implement createRedirectURL with form_post
    Refactor extractIdTokenFromCallback to accept unknown payload
    Update response_mode to form_post
► src/frontend/src/routes/(new-styling)/authorize/+page.svelte
    Implement logic to resume OpenID flow
    Remove direct handling of callback URL parameters
    Use extractIdTokenFromCallback for JWT extraction
► src/frontend/src/routes/(new-styling)/callback-error/+page.svelte
    Add callback-error page component
► src/frontend/src/routes/+layout.svelte
    Remove /callback path from redirect exclusion
► src/internet_identity_frontend/Cargo.toml
    Add serde and form_urlencoded dependencies
    Add proptest dependency
► src/internet_identity_frontend/internet_identity_frontend.did
    Add http_request_update method
► src/internet_identity_frontend/src/callback.rs
    Implement callback handler for form_post
    Implement CallbackPayload enum
    Implement payload JSON serialization and rendering
    Implement redirect to error page logic
    Implement validation for form post fields
    Add constants for callback paths and field limits
    Add tests for callback parsing and rendering
Refactor ► src/frontend/src/routes/(new-styling)/callback/+page.svelte
    Remove callback page component
► src/frontend/src/routes/(new-styling)/callback/utils.ts
    Remove callback utility functions

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the Internet Identity frontend OpenID/OAuth callback handling to use response_mode=form_post instead of URL fragments, enabling stricter OIDC provider compatibility (Okta/Auth0) and avoiding id_token exposure in callback URLs. It adds a certified canister-side /callback POST translator that delivers {id_token,state} (or RFC6749 errors) to the SPA via BroadcastChannel (popup) or sessionStorage (same-tab resume), with dev-server parity via a SvelteKit hook.

Changes:

  • Add a canister update-path (http_request upgrade → http_request_update) to translate POST /callback form bodies into a certified HTML landing page.
  • Update frontend OpenID utilities and authorize-resume flow to consume the structured callback payload (instead of parsing URL fragments).
  • Adjust dev hot-reload behavior to translate /callback POSTs in hooks.server.ts and disable SvelteKit CSRF origin checking to allow cross-origin IdP form POSTs in dev.

Reviewed changes

Copilot reviewed 14 out of 15 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
svelte.config.js Disables SvelteKit CSRF origin check to allow cross-origin POST /callback in dev.
src/internet_identity_frontend/tests/integration/http.rs Adds PocketIC integration coverage for callback upgrade + translation behaviors.
src/internet_identity_frontend/src/main.rs Adds upgrade-to-update routing for POST /callback and introduces http_request_update.
src/internet_identity_frontend/src/callback.rs New bounded parser + certified HTML landing page generator for response_mode=form_post.
src/internet_identity_frontend/internet_identity_frontend.did Exposes http_request_update in the canister interface.
src/internet_identity_frontend/Cargo.toml Adds form_urlencoded dependency for parsing callback form bodies.
src/frontend/src/routes/+layout.svelte Removes legacy /callback exemption from client-side redirect logic.
src/frontend/src/routes/(new-styling)/callback/utils.ts Deletes legacy fragment-era callback popup utilities.
src/frontend/src/routes/(new-styling)/callback/+page.svelte Deletes legacy fragment-era callback page.
src/frontend/src/routes/(new-styling)/authorize/+page.svelte Updates same-tab resume flow to read payload from sessionStorage and validate via extractIdTokenFromCallback.
src/frontend/src/lib/utils/openID.ts Moves popup utilities here; requests form_post; validates structured callback payload.
src/frontend/src/lib/utils/openID.test.ts Updates unit tests for structured payload parsing and form_post redirect URL creation.
src/frontend/src/hooks.server.ts Implements dev-server stand-in for canister /callback translation.
src/canister_tests/src/api.rs Adds http_request_update helper to simulate HTTP gateway upgrade behavior.
Cargo.lock Records the new form_urlencoded dependency.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/internet_identity_frontend/src/callback.rs Outdated
Comment thread src/internet_identity_frontend/src/main.rs
sea-snake and others added 2 commits June 12, 2026 13:45
Replace the hand-rolled Reflect.get-based property reader and shape
guard with Zod schemas, matching how the rest of the frontend validates
untrusted boundary data (e.g. the ICRC channel handlers, ssoDiscovery,
auth-handoff). isCallbackPayload becomes a strict union safeParse;
extractIdTokenFromCallback reads fields through a lenient schema that
still runs the CSRF state check first and treats any non-string field as
absent. The schema accepts the JSON null the canister emits for an absent
error_description and normalizes it to undefined.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses Copilot review on #4015.

The /callback landing page inherited the SPA-wide CSP, whose
`script-src 'self' 'unsafe-inline' 'unsafe-eval'` (needed for SvelteKit
and agent-js wasm) left the computed inline-script hash doing nothing
unless the browser applied the CSP3 rule that a hash disables
'unsafe-inline'. Give the page its own policy instead —
`default-src 'none'; script-src 'sha384-...'; base-uri 'none';
frame-ancestors 'none'` (error page: no script-src) — so the hash
actually governs execution and 'unsafe-eval'/'self' are gone. The
SPA-wide CSP is replaced on this response, not appended.

Also narrow the 405 `Allow` header from `GET, POST` to `GET`:
method_not_allowed is only reached for non-/callback resources, which
serve GET only (POST is handled on /callback via the query->update
upgrade).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sea-snake

Copy link
Copy Markdown
Contributor Author

Thanks @copilot — both addressed in 50a8af1:

  • CSP hash pinning: the /callback response now gets its own policy (default-src 'none'; script-src 'sha384-…'; base-uri 'none'; frame-ancestors 'none'; the error page has no script-src) instead of inheriting the SPA-wide script-src 'self' 'unsafe-inline' 'unsafe-eval'. The SPA CSP is replaced on this response, not appended, so the hash actually governs execution rather than relying on the CSP3 rule that a hash makes browsers ignore unsafe-inline. Unit + PocketIC integration tests now assert exactly one CSP header carrying the hash and no unsafe-inline/unsafe-eval.
  • 405 Allow header: narrowed from GET, POST to GETmethod_not_allowed is only reached for non-/callback resources, which serve GET only (POST is handled on /callback via the query→update upgrade).

…eimplementing it

The dev-server hook reimplemented the form_post → HTML translation in
TypeScript, duplicating the canister's callback.rs (escaping, the
flow-discriminator script, the payload shape). The frontend canister is
always installed when working on OpenID, and the hook already round-trips
to it to recover the injected <body> tag — so instead forward the
/callback POST to the deployed canister and return its certified response.
Dev now exercises the real translator; there is a single source of truth.

Also migrate the CSRF escape hatch from the now-deprecated
`csrf.checkOrigin: false` to `csrf.trustedOrigins: ['*']`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sea-snake sea-snake requested review from MRmarioruci and aterga June 12, 2026 15:04
Comment thread src/internet_identity_frontend/internet_identity_frontend.did Outdated
Comment thread src/internet_identity_frontend/tests/integration/http.rs
Comment thread src/internet_identity_frontend/tests/integration/http.rs
Comment thread src/internet_identity_frontend/src/main.rs Outdated
Comment thread src/internet_identity_frontend/src/callback.rs Outdated
Comment thread src/internet_identity_frontend/src/callback.rs Outdated
Comment thread src/internet_identity_frontend/src/callback.rs Outdated
Comment thread src/internet_identity_frontend/src/callback.rs Outdated
Comment thread src/internet_identity_frontend/src/callback.rs Outdated
Comment thread src/internet_identity_frontend/src/callback.rs
Comment thread src/internet_identity_frontend/src/callback.rs Outdated
Comment thread src/internet_identity_frontend/src/callback.rs Outdated
Comment thread src/frontend/src/routes/(new-styling)/authorize/+page.svelte Outdated
Comment thread src/internet_identity_frontend/src/callback.rs Outdated

@aterga aterga left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM modulo comments

sea-snake and others added 2 commits June 19, 2026 14:58
- redirect malformed callbacks to a new in-SPA /callback-error page
  instead of returning inline HTML (dev hook passes the 303 through)
- serialize the payload with a serde derive on CallbackPayload
- thread a CSP override through dynamic_response_headers instead of
  filtering out and re-adding the header in the callback module
- report the observed size and the limit in parser bound errors
- property-test the charset predicates and the JSON embedding
- drop the .did comment, rename the JSON data-block id to
  callback-payload, restructure the parser into guard clauses, and
  remove stale historical comments

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolves the one conflict in src/internet_identity_frontend/src/main.rs:
main added an `mcp_server_origin: Option<&str>` parameter to
get_asset_headers / get_content_security_policy, while this branch added
a `content_security_policy_override: Option<String>`. Combined both:
get_asset_headers now takes both, mcp_server_origin is persisted in
HeaderConfig so dynamic_response_headers can pass it, and the override
short-circuits the computed CSP (which now also threads mcp_server_origin).
@aterga aterga enabled auto-merge (squash) June 19, 2026 20:40
@aterga aterga merged commit 1c24dff into main Jun 19, 2026
79 of 80 checks passed
@aterga aterga deleted the form-post-callback branch June 19, 2026 20:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants